# timetable.rb
# Copyright (c) 2009 Alex Wilson
# Licensed under the MIT license (see LICENSE in the distribution root dir)

require 'time'
require 'date'
require 'rubygems'
require 'vpim/icalendar'
require 'uuid'
require 'icalendar'
require 'set'

# The Timetable module
#
# Author:: Alex Wilson <alex@rijidij.org>

module Timetable

include Icalendar

# Session objects represent an individual time event on the timetable. They contain most of the temporal
# information.
class Session
  # The hash from which this session is constructed (raw data form mySInet)
	attr_reader :hash
	# Course code (eg COUR1234)
	attr_reader :course
	# Series identifier (eg L for lecture)
	attr_reader :series
	# Series number (eg 1 with series == 'L' for L1)
	attr_reader :number
	# DateTime when the first instance of the session starts
	attr_reader :start
	# DateTime when the session ends
	attr_reader :finish

	@@clash_cache = Hash.new

	# Takes the hash obtained from parsing a SI-net page
	def initialize(hash)
		@hash = hash
		m = /([A-Z]*)([0-9]*)/.match(@hash['Class'])
		@course = @hash['Course']
		@series = m[1]
		@number = m[2]
		
		extra_init
		
		Group.find(@course, @series, @number).sessions << self
	end
	
	# Performs extra initialization functions
	#
	# Anything that happens here will also happen when we are un-Marshalled
	# from a data file
	#
	def extra_init(hash=nil)
		hash = @hash if not hash
		@from, @til = @hash['Start/End Date (DD/MM/YYYY)'].split(" - ").collect { |s| DateTime.strptime("#{s} +1000", "%d/%m/%Y %z") }
		@start = DateTime.strptime("#{@from.cweek-1} #{@hash['Weekday']} #{@hash['Start']} +1000", "%W %a %I:%M %p %z")
		@finish = DateTime.strptime("#{@from.cweek-1} #{@hash['Weekday']} #{@hash['End']} +1000", "%W %a %I:%M %p %z")
		
		if @hash['Not taught on these dates (DD/MM/YYYY)'] != "&nbsp;"
			@exdates = @hash['Not taught on these dates (DD/MM/YYYY)'].split(";").collect { |s| st = s.strip + " "+@hash['Start']+" +1000"; DateTime.strptime(st, "%d/%m/%Y %I:%M %p %z") }
		else
			@exdates = []
		end
		
		@nextof_cache = Hash.new
		@same_day_cache = Hash.new
		@recur_cache = nil
		
		recur_series
	end
	
	# Returns the entire recurrence set of the session
	# as Dates (NOT Datetimes)
	def recur_series	
		return @recur_cache if @recur_cache
		
		curdate = Date.parse(@start.strftime("%Y-%m-%d"))
		exdates = Hash.new
		@exdates.each do |ex| 
			exdates[Date.parse(ex.strftime("%Y-%m-%d"))] = true
		end
		series = Array.new
		@dhashes = Array.new
		while curdate <= @til
			series << curdate unless exdates[curdate]
			# exdate == true, means not taught
			@dhashes << { :date => curdate.strftime("%d/%m"), :taught => (exdates[curdate] != true) }
		
			# advance one week
			curdate = curdate + 7
		end
		@recur_cache = Set.new(series)
	end
	
	# Returns true if this Session clashes with the other given
	def clashes_with?(other)
		return @@clash_cache[ [self.object_id, other.object_id] ] if @@clash_cache[ [self.object_id, other.object_id] ]
		return @@clash_cache[ [other.object_id, self.object_id] ] if @@clash_cache[ [other.object_id, self.object_id] ]
		
		#puts "checking clashes_with?"
		
		# we have to examine the exceptions and weeks taught
		# and check for conflict that way
		my_series = recur_series
		other_series = other.recur_series
		clashes = my_series.intersection(other_series)
		
		# if we have more than 1/3rd of the average size of the groups
		# clashing then check for a direct time clash
		if clashes.size > ((my_series.size + other_series.size) / 2) / 3
			@@clash_cache[ [self.object_id,other.object_id] ] = ((other.start >= @start and other.start <= @finish) or (other.finish >= @start and other.finish <= @finish))
		else
			@@clash_cache[ [self.object_id,other.object_id] ] = false
		end
		
		#puts "check done"
		
		@@clash_cache[ [self.object_id,other.object_id] ]
	end
	
	# Returns true if this Session is considered 'optional' -- ie, it is:
	# - a PASS session;
	# - the 'Class Type' field contains the word 'optional'; or
	# - it has been declared optional using Session.make_optional
	def is_optional?
		case
			when @hash['Class Type'].downcase.include?('option')
				true
			when @hash['Class Type'].downcase.include?('pass')
				true
			when @@optionals.include?([@course, @series])
				true
			else
				false
		end
	end
	
	# Returns the next (temporally) event on the same day as this event
	# selected from the Array of others
	def next_of(others)
		other_ids = others.collect { |o| o.object_id }
		return @nextof_cache[other_ids] if @nextof_cache[other_ids]
		
		closest = [-1, nil]
		
		i = 0
		while i < others.size
		  d = time_until(others[i])
		  if d and ((closest[0] == -1) or (d < closest[0]))
		    closest = [d, others[i]]
	    end
		  i += 1
	  end
	  
	  @nextof_cache[other_ids] = closest
	end
	
	# Returns the time until another event
	def time_until(other)
		return other.start - @finish if (other.start > @finish) and (other.start.wday == @finish.wday)
		nil
	end
	
	# Returns the number of events in the given list on the same day as this
	# session.
	def same_day_as(others)
	  other_ids = others.collect{ |o| o.object_id }
	  return @same_day_cache[other_ids] if @same_day_cache[other_ids]
	  
	  sdc = Array.new
	  i = 0
	  while i < others.size
	    o = others[i]
	    if o.start.wday == @finish.wday and o.start != @start and o.finish != @finish
	      sdc << o
      end
	    i += 1
    end
	  
	  @same_day_cache[other_ids] = sdc
  end
	
	# Adds this Session as an event on the the vPim calendar given
	def add_to_calendar(cal)
		evhash = Hash.new
		evhash['DTEND'] = @finish.new_offset().to_ical + 'Z'
		evhash['SUMMARY'] = "#{@course} #{@series}#{@number}"
		evhash['SEQUENCE'] = "0"
		evhash['UID'] = UUID.generate
		evhash['DTSTAMP'] = DateTime.now().new_offset().to_ical + 'Z'
		evhash['RRULE'] = "FREQ=WEEKLY;UNTIL=#{@til.new_offset().to_ical + 'Z'}"
		if @hash['Not taught on these dates (DD/MM/YYYY)'] != "&nbsp;"
			exdates_cal = @exdates.collect { |d| d.new_offset().to_ical + 'Z' }
			evhash['EXDATE'] = exdates_cal.join("\nEXDATE:")
		end
		evhash['LOCATION'] = "#{@hash['Building Name']} #{@hash['Building']}-#{@hash['Room']}"
	
		ev = Vpim::Icalendar::Vevent.create(@start.new_offset().to_ical + 'Z', evhash)
		
		cal << ev
	end
	
	# Gets a JSON respresentation of the Session
	def to_json
	  {
	    :wday => @start.wday,
	    :start => @start.hour + (@start.min/60.0),
	    :finish => @finish.hour + (@finish.min/60.0),
	    :text => "<span>#{@start.strftime("%l %p").strip.downcase}<br/>#{@course} #{@series}#{@number}</span>",
	    :text_time => @start.strftime("%l %p").strip.downcase,
	    :name => "#{@course} #{@series}#{@number}",
	    :location => "#{hash['Building Name']} #{@hash['Building']}-#{@hash['Room']}",
	    :date_series => @dhashes
	  }.to_json
	end
	
	@@optionals = Array.new
	# Make a sepcified course series 'optional'
	def Session.make_optional(course, series)
		@@optionals << [course, series]
	end
end

# A timetable group (see README)
class Group
	@@groups = Hash.new

  # The course identifier
	attr_reader :course
	# The series identifier
	attr_reader :series
	# The series number that identifies this group
	attr_reader :number
	# Array of Session objects, the sessions in this group
	attr_accessor :sessions

	def initialize(course, series, number)
		@course = course
		@series = series
		@number = number
		@sessions = Array.new
		
		register
		Series.find(course, series).groups[number] = self
	end
	
	# Adds this group to the global registry of course groups
	def register
		@@groups[ [course, series, number] ] = self
	end
	
	# Removes this group from the global registry of course groups
	def unregister
		@@groups.delete([course,series,number])
		@sessions = Array.new
	end
	
	# Returns this course's JSON representation
	def to_json
		"[" + @sessions.collect{ |s| s.to_json }.join(",") + "]"
	end
	
	# Calculates the 'clash weight' between this group and another -- based on
	# what fraction of the groups' sessions clash with each other.
	def clash_weight(other)
	  sum = 0.0
	  i = 0
	  while i < @sessions.size
	    j = 0
	    while j < other.sessions.size
	      if @sessions[i].clashes_with?(other.sessions[j])
	        if @sessions[i].is_optional? or other.sessions[j].is_optional?
	          sum += 0.0
	        else
	          sum += 1.0
	        end
        end
	      j += 1
      end
	    i += 1
    end
    sum
	end
	
	# Adds this group to the iCal object given
	def add_to_calendar(cal)
		@sessions.each { |s| s.add_to_calendar(cal) }
	end
	
	# Finds a group in the global group registry
	def Group.find(course, series, number)
		return @@groups[ [course, series, number] ] if @@groups[ [course, series, number] ]
		Group.new(course, series, number)
	end
end

# A timetable series (see README)
class Series
	@@series = Hash.new
	
	# The course code
	attr_reader :course
	# The series identifier
	attr_reader :series
	# Array of groups available in this series
	attr_accessor :groups
	
	def initialize(course, series)
		@course = course
		@series = series
		@groups = Hash.new
		
		register
		Course.find(course).series[series] = self
	end
	
	# Adds this series to the global series registry
	def register
		@@series[ [course, series] ] = self
	end
	
	# Removes this series from the global series registry
	def unregister
		@@series.delete([course,series])
	end
	
	# Finds a series in the global registry
	def Series.find(course, series)
		return @@series[ [course, series] ] if @@series[ [course, series] ]
		Series.new(course, series)
	end
	
	# Returns an array of all series registered
	def Series.all()
		return @@series.values
	end
end

# Class representing a timetable Course, containing all the sub-objects
# from the Series, Group and Session classes.
#
# Courses also are capable of dumping themselves to disk using Marshal.
#
class Course
	@@courses = Hash.new
	
	# The course code
	attr_reader :course
	# Array of series in this course
	attr_reader :series
	
	def initialize(course)
		@@courses[ course ] = self
		@course = course
		@series = Hash.new
	end
	
	# Removes this course from the global registry
	def unregister
		@@courses.delete(@course)
		@series.each do |k, s|
			s.unregister
			s.groups.each do |k, g|
				g.unregister
			end
		end
	end
	
	# Dumps this course to the given datafile, recording the dump
	# in the index file specified.
	def dump(fname="data/#{@course}.dat", indexfile="data/index.dat")
		# first save the course
		hash = Hash.new
		File.open(fname) { |f| hash.update Marshal.load(f.read) } if File.file?(fname)
		hash.update(@course => self)
		File.open(fname, "w") { |f| f.write Marshal.dump(hash) }
		
		# now update the index
		hash = Hash.new
		File.open(indexfile) { |f| hash.update Marshal.load(f.read) } if File.file?(indexfile)
		hash.update(@course => fname)
		hash.update("#{@course}_ts" => DateTime.now())
		File.open(indexfile, "w") { |f| f.write Marshal.dump(hash) }
	end
	
	# Uses the given index file to load the course code specified, if possible.
	def Course.load(course, indexfile="data/index.dat")
		hash = Hash.new
		File.open(indexfile) { |f| hash.update Marshal.load(f.read) } 
		
		fname = "data/#{course}.dat"
		fname = hash[course] if hash[course]
		
		Course.load_datfile(fname)
	end
	
	# Loads all courses available from the given data file.
	def Course.load_datfile(fname)
		hash = Hash.new
		File.open(fname) { |f| hash.update Marshal.load(f.read) }
		@@courses.update(hash)
		
		hash.each do |k, c|
			c.series.each do |k, s|
				s.register
				s.groups.each do |k, g|
					g.sessions.each do |s|
						s.extra_init
					end
					g.register
				end
			end
		end
	end
	
	# Returns the contents of an index file as an array of all available
	# courses.
	def Course.get_index(indexfile="data/index.dat")
		hash = Hash.new
		File.open(indexfile) { |f| hash.update Marshal.load(f.read) }
		hash.keys.select { |k| not k.include?("_ts") }
	end
	
	# Returns the timestamps recorded in an index file, as a hash of
	# course => datetime pairs.
	def Course.get_tstamps(indexfile="data/index.dat")
		hash = Hash.new
		File.open(indexfile) { |f| hash.update Marshal.load(f.read) }
		rethash = Hash.new
		hash.each do |k,fname|
			rethash[k] = hash["#{k}_ts"] if not k.include?("_ts")
		end
		rethash
	end
	
	# Loads all courses registered in the given index file.
	def Course.load_all(indexfile="data/index.dat")
		return if not File.file?(indexfile)
		hash = Hash.new
		File.open(indexfile) { |f| hash.update Marshal.load(f.read) }
		
		hash.each do |k,fname|
			Course.load_datfile(fname) if not k.include?("_ts")
		end
	end
	
	# Finds a course in the global registry, creating it if
	# it does not yet exist
	def Course.find(course)
		return @@courses[course] if @@courses[course]
		Course.new(course)
	end
	
	# Finds a course in the global registry, does not create
	def Course.find_if(course)
		@@courses[course]
	end
	
	# Returns an array of all loaded Courses.
	def Course.all()
		return @@courses.values
	end
end

end
